SPB Git forge
3commits 1branches 0releases
417.0 KBsize
maindefault branch
10 days agolast push
TypeScript 66.5% Python 30.9% JavaScript 1.4% CSS 0.7%
7.2 KB · 121 lines tsx
Raw Blame History
1import type { Metadata } from 'next';2import Link from 'next/link';3import { notFound } from 'next/navigation';4import { Bars, HBars } from '@/components/charts/charts';5import { LaunchesTable } from '@/components/launches/launches-table';6import { SitesMap } from '@/components/launches/sites-map';7import { Block, Empty, Head } from '@/components/satellite/primitives';8import { Container, Stat } from '@/components/ui/section';9import { Unavailable } from '@/components/ui/unavailable';10import { api, ApiError, safe } from '@/lib/api';11import { fmtDate, fmtInt, num } from '@/lib/format';12import { routes, SITE_URL } from '@/lib/site';1314type Params = { params: Promise<{ slug: string }> };15type SiteDetail = Awaited<ReturnType<typeof api.launchSite>>['data'];1617async function load(slug: string): Promise<SiteDetail> {18  try {19    return (await api.launchSite(slug)).data;20  } catch (e) {21    if (e instanceof ApiError && e.notFound) notFound();22    throw e;23  }24}2526/** The detail endpoint has no totals; derive them from the per-year series (real data, no guesses). */27function totals(s: SiteDetail) {28  const launches = s.years.reduce((a, y) => a + (num(y.launches) ?? 0), 0);29  const payloads = s.years.reduce((a, y) => a + (num(y.payloads) ?? 0), 0);30  const first = s.years[0]?.year ?? null;31  const last = s.years[s.years.length - 1]?.year ?? null;32  const lastLaunch = s.recent_launches.reduce<string | null>((m, l) => (l.launch_date && (!m || l.launch_date > m) ? l.launch_date : m), null);33  return { launches, payloads, first, last, lastLaunch };34}3536export async function generateMetadata({ params }: Params): Promise<Metadata> {37  const { slug } = await params;38  const res = await safe(api.launchSite(slug));39  if (!res) return { title: 'Launch site', robots: { index: false } };40  const s = res.data;41  const t = totals(s);42  const title = `${s.name} — Launch site${s.country_name ? `, ${s.country_name}` : ''}`;43  const description = `${s.name}${s.country_name ? ` (${s.country_name})` : ''}: ${fmtInt(t.launches)} orbital launches and ${fmtInt(t.payloads)} payloads${t.first ? ` since ${t.first}` : ''}${t.lastLaunch ? `, most recent on ${fmtDate(t.lastLaunch)}` : ''}. Launches per year, recent launches and top owners on SatelliteIndex.`;44  const canonical = routes.launchSite(s.slug);45  return { title, description, alternates: { canonical }, openGraph: { title, description, url: `${SITE_URL}${canonical}` }, twitter: { card: 'summary', title, description } };46}4748export default async function LaunchSitePage({ params }: Params) {49  const { slug } = await params;50  const s = await load(slug);51  const t = totals(s);52  const hasCoords = s.latitude !== null && s.longitude !== null;53  const yearsData = s.years.map((y) => ({ x: String(y.year), y: num(y.launches) ?? 0 }));54  const owners = [...s.owners].sort((a, b) => (num(b.launches) ?? 0) - (num(a.launches) ?? 0)).slice(0, 12);55  const jsonLd = { '@context': 'https://schema.org', '@type': 'Place', name: s.name, url: `${SITE_URL}${routes.launchSite(s.slug)}`, identifier: s.code, ...(hasCoords ? { geo: { '@type': 'GeoCoordinates', latitude: s.latitude, longitude: s.longitude } } : {}), ...(s.country_name ? { address: { '@type': 'PostalAddress', addressCountry: s.country_code ?? s.country_name } } : {}) };5657  return (58    <Container wide>59      <script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} />60      <header className="pb-6 pt-6 md:pt-10">61        <p className="eyebrow mono">62          Launch site · {s.code}63          {hasCoords && <> · {s.latitude!.toFixed(2)}°, {s.longitude!.toFixed(2)}°</>}64        </p>65        <h1 className="display mt-2 break-words text-3xl md:text-5xl">{s.name}</h1>66        <p className="mt-3 text-sm text-ink-2">67          {s.country_slug ? <Link href={routes.country(s.country_slug)} className="link">{s.country_name}</Link> : s.country_name ?? 'Country unknown'}68          {' · '}69          <Link href={routes.launches(`site=${encodeURIComponent(s.slug)}`)} className="link">All launches from this site</Link>70        </p>71      </header>7273      <div className="grid gap-8 lg:grid-cols-[minmax(0,1fr)_minmax(0,1fr)] lg:items-start">74        <div className="grid grid-cols-2 gap-x-4 gap-y-6 sm:grid-cols-4 lg:grid-cols-2">75          <Stat label="Orbital launches" value={fmtInt(t.launches)} hint={t.first ? `since ${t.first}` : undefined} />76          <Stat label="Payloads" value={fmtInt(t.payloads)} />77          <Stat label="Active years" value={fmtInt(s.years.length)} hint={t.first && t.last ? `${t.first} – ${t.last}` : undefined} />78          <Stat label="Most recent launch" value={<span className="text-xl md:text-2xl">{fmtDate(t.lastLaunch)}</span>} hint={t.lastLaunch ? undefined : 'no dated launch in the recent list'} />79        </div>80        <div>81          {hasCoords ? <SitesMap sites={[{ code: s.code, name: s.name, slug: s.slug, country_code: s.country_code, latitude: s.latitude, longitude: s.longitude, launches: t.launches, launches_last_365d: 0, last_launch: t.lastLaunch }]} highlight={s.slug} labelTop={1} /> : <Unavailable what="Site coordinates" compact />}82        </div>83      </div>8485      <div className="mt-6 divide-y divide-[color:var(--rule)]">86        <Block id="years">87          <Head eyebrow="Cadence" title="Launches per year" />88          {yearsData.length ? <Bars data={yearsData} title={`Launches per year from ${s.name}`} height={200} xTicks={10} highlightLast /> : <Empty>No dated launches on file.</Empty>}89        </Block>9091        <Block id="recent">92          <Head eyebrow="Recent" title={<>Recent launches <span className="tnum text-ink-3">· {fmtInt(s.recent_launches.length)}</span></>} action={{ href: routes.launches(`site=${encodeURIComponent(s.slug)}`), label: 'Full list' }} />93          {s.recent_launches.length ? <LaunchesTable rows={s.recent_launches} showSite={false} ownerHref={(c) => routes.launches(`site=${encodeURIComponent(s.slug)}&owner=${encodeURIComponent(c)}`)} /> : <Empty>No launches recorded for this site.</Empty>}94        </Block>9596        <Block id="owners">97          <Head eyebrow="Customers" title="Top owners launched from here" />98          {owners.length ? (99            <HBars data={owners.map((o) => ({ label: `${o.name} (${o.code})`, value: num(o.launches) ?? 0 }))} max={num(owners[0]?.launches) ?? undefined} />100          ) : (101            <Empty>No owner information for the objects launched from this site.</Empty>102          )}103          {owners.length > 0 && (104            <ul className="mt-3 flex flex-wrap gap-1.5">105              {owners.map((o) => (106                <li key={o.code}>107                  <Link href={routes.launches(`site=${encodeURIComponent(s.slug)}&owner=${encodeURIComponent(o.code)}`)} className="mono inline-flex min-h-9 items-center rounded-md border border-rule px-2.5 text-xs text-ink-2 hover:border-rule-strong hover:text-ink">{o.code}</Link>108                </li>109              ))}110            </ul>111          )}112        </Block>113      </div>114115      <p className="pb-10 pt-4 text-2xs text-ink-3">116        Totals are summed from the per-year series of launches attributed to site code {s.code} in SATCAT; launches are derived from international designators (<Link href={routes.methodology()} className="hover:text-accent">methodology</Link>).117      </p>118    </Container>119  );120}121